/**
 * @fileoverview UI components for use with the google-docs.js library.
 * Includes a DocPicker (for selecting a document from a user's account) and a
 * DocViewer (for displaying a selected document).
 */
goog.provide('gdata.docs.ui');
goog.provide('gdata.docs.ui.DocPicker');
goog.provide('gdata.docs.ui.DocViewer');
goog.provide('gdata.docs.ui.DocViewer.EventType');

goog.require('gdata.auth');
goog.require('gdata.docs.DocumentListFeed');
goog.require('gdata.auth.LoginFailure');
goog.require('gdata.auth.ServiceName');
goog.require('gdata.auth.User');
goog.require('gdata.viewer.templates');

goog.require('goog.async.Deferred');
goog.require('goog.dom');
goog.require('goog.dom.DomHelper');
goog.require('goog.events.Event');
goog.require('goog.events.EventHandler');
goog.require('goog.events.EventTarget');
goog.require('goog.events.EventType');
goog.require('goog.functions');
goog.require('goog.positioning.Corner');
goog.require('goog.string');
goog.require('goog.ui.Component.EventType');
goog.require('goog.ui.MenuItem');
goog.require('goog.ui.PopupMenu');
goog.require('soy');


/**
 * @param {gdata.auth.User} user
 * @param {Element} pickerEl Element into which the picker should be drawn.
 * @param {gdata.docs.ui.DocViewer} viewer If specified, the viewer into which the document
 *     that is picked will be displayed. If null, picked documents will open in
 *     a new window.
 * @param {Object=} options May have the following keys:
 *     <li> {Object} documentIdFilter If specified, only show the documents
 *         whose id is a key in documentIdFilter.
 * @constructor
 * @extends {goog.events.EventTarget}
 */
gdata.docs.ui.DocPicker = function(user, pickerEl, viewer, options) {
  goog.base(this);

  this.user_ = user;
  this.pickerEl_ = pickerEl;

  this.viewer_ = viewer;

  /**
   * @type {Object|undefined}
   * @private
   */
  this.documentIdFilter_ = options && options.documentIdFilter;

  this.idToEntryMap_ = {};
  this.handler_ = new goog.events.EventHandler(this);

  goog.exportSymbol('pickerInsertHtml', gdata.docs.ui.insertHtml);
  goog.exportSymbol('pickerOnFrameLoad', gdata.docs.ui.onFrameLoad);
  
  var id = gdata.docs.ui.pickerCounter_++;
  this.id_ = id;
  gdata.docs.ui.pickerMap_[id] = this;
  
  this.displayLoginForm();
};
goog.inherits(gdata.docs.ui.DocPicker, goog.events.EventTarget);

/**
 * @type {number}
 */
gdata.docs.ui.pickerCounter_ = 0;

/**
 * @type {Object}
 */
gdata.docs.ui.pickerMap_ = {};


/**
 * @type {goog.dom.DomHelper}
 */
gdata.docs.ui.DocPicker.prototype.pickerDom_;

/**
 * @type {goog.ui.PopupMenu}
 * @private
 */
gdata.docs.ui.DocPicker.prototype.popupMenu_;

/**
 * Invoke this method to display the login form. This may have to be called
 * again if the user's GData token expires during the session.
 */
gdata.docs.ui.DocPicker.prototype.displayLoginForm = function() {
  this.pickerEl_.innerHTML = '<iframe src="javascript:parent.pickerInsertHtml(' + this.id_ +
      ')" frameborder="0" marginheight="0" marginwidth="0"></iframe>';
};

/** @return {goog.dom.DomHelper} */
gdata.docs.ui.DocPicker.prototype.getPickerDom = function() {
  return this.pickerDom_;
};

/** @return {boolean} */
gdata.docs.ui.DocPicker.prototype.hasViewer = function() {
  return this.viewer_ != null;
};

/** @return {gdata.docs.ui.DocViewer} */
gdata.docs.ui.DocPicker.prototype.getViewer = function() {
  return this.viewer_;
};

/** @return {gdata.docs.DocumentListFeed} */
gdata.docs.ui.DocPicker.prototype.getDocumentListFeed = function() {
  return this.doclist_;
};

/**
 * @param {number} id
 */
gdata.docs.ui.insertHtml = function(id) {
  return gdata.viewer.templates.pickerBase({id: id});
};

/**
 * @param {number} id
 * @param {Element} bodyEl
 */
gdata.docs.ui.onFrameLoad = function(id, bodyEl) {
  var docPicker = gdata.docs.ui.pickerMap_[id];
  docPicker.pickerDom_ = goog.dom.getDomHelper(bodyEl);
  docPicker.displayDocumentsForUser();
};


/** Processes the login form associated with this picker. */
gdata.docs.ui.DocPicker.prototype.processLogin = function() {
  var domHelper = this.getPickerDom();
  var username = domHelper.$('username').value;
  if (username.indexOf('@') < 0) {
    username += '@gmail.com';
  }
  var password = domHelper.$('password').value;
  var user = new gdata.auth.User(username);
  var services = [gdata.auth.ServiceName.DOCUMENTS_LIST,
                  gdata.auth.ServiceName.SPREADSHEETS];
  if (password) {
    try {
      user.login(password, services);
      this.displayDocumentsForUser();
    } catch (e) {
      if ((e instanceof gdata.auth.LoginFailure) &&
          (e.request.status == 401 || e.request.status == 403)) {
        alert('It appears your username/password combination is invalid. ' +
            'Please check your username and password and try again.');
      } else {
        throw e;
      }
    }
  } else if (gdata.auth.lookupAuthToken(gdata.auth.ServiceName.DOCUMENTS_LIST)) {
    if (user.isAuthenticated(gdata.auth.ServiceName.DOCUMENTS_LIST)) {
      this.displayDocumentsForUser();
    } else {
      alert('It appears your GData token expired. ' +
          'Please enter your username and password and try again.');
      var email = user.getEmail();
      goog.array.forEach(services, function(service) {
        gdata.auth.deleteAuthToken(email, service);
      });
    }
  } else {
    alert('You must enter a password');
  }
};

/**
 * Refreshes the document list and displays the specified doc id.
 * @param {string} newDocId
 */
gdata.docs.ui.DocPicker.prototype.refresh = function(newDocId) {
  // TODO(bolinfest): Do all of this using fewer HTTP requests. Currently, this
  // opts for convenience over performance since using fewer requests requires
  // updating more data structures on the client rather than blowing them away.

  // Clear listeners added during the previous invocation.
  this.handler_.removeAll();

  // Reset the URL to Entry map.
  this.idToEntryMap_ = {};

  if (this.documentIdFilter_) {
    this.documentIdFilter_[newDocId] = true;
  }
  
  var deferred = this.displayDocumentsForUser();

  // Once it refreshes, the gdata.docs.DocumentListEntry that wraps the document
  // should be available. Use it to write the default HTML for the document and
  // then open it.
  deferred.addCallback(function() {
    var entry = this.getEntry(newDocId);
    var callback = goog.bind(function() {
      this.displayEntry(newDocId);
    }, this);
    var errback = function() {
      alert('Failed to load new document');
    };
    debugger;
    entry.update(gdata.viewer.templates.newDocumentHtml(),
        callback,
        errback);
  }, this);
};

/**
 * Lists the word-processing documents for the specified user.
 * @return {goog.async.Deferred}
 */
gdata.docs.ui.DocPicker.prototype.displayDocumentsForUser = function() {
  var domHelper = this.getPickerDom();
  var user = this.user_;
  var content = domHelper.getElement('content');
  var soyData = {
    showEmail: false,
    email: user.getEmail()
  };
  soy.renderElement(content, gdata.viewer.templates.picker, soyData);
  var doclist = this.doclist_ = new gdata.docs.DocumentListFeed(user);
  // TODO(mbolin): Display "Loading..." while docs are being fetched.
  var self = this;
  var deferred = new goog.async.Deferred();
  doclist.fetchDocuments(
  /**
   * @param {Array.<gdata.docs.DocumentListEntry>} entries
   */
  function(entries) {
    var isValidId = self.documentIdFilter_ ?
        function(id) { return id in self.documentIdFilter_; } :
        goog.functions.TRUE;

    self.popupMenu_ = self.createMenu();
    self.handler_.listen(
        self.popupMenu_,
        goog.ui.Component.EventType.ACTION,
        self.onMenuItemClicked_);

    var soyDataEntries = [];
    for (var i = 0; i < entries.length; ++i) {
      var entry = entries[i];
      if (!entry.isDocument() || !isValidId(entry.getId())) {
        continue;
      }

      var soyData = {
        url: entry.getUrl(),
        title: entry.getTitle(),
        id: entry.getId()
      };
      soyDataEntries.push(soyData);

      self.idToEntryMap_[entry.getId()] = entry;
    }

    var doclistEl = domHelper.getElement('doclist');
    soy.renderElement(
        doclistEl,
        gdata.viewer.templates.list,
        {hasViewer: self.hasViewer(), entries: soyDataEntries});

    self.handler_.listen(doclistEl, goog.events.EventType.CLICK, self.handleClick_);

    deferred.callback(self);
  }, function(error) {
    alert(error);
    deferred.errback(error);
  });
  
  return deferred;
};

/**
 * @param {string} id
 */
gdata.docs.ui.DocPicker.prototype.displayEntry = function(id) {
  var entry = this.idToEntryMap_[id];
  if (entry) this.getViewer().display(entry);  
};

/**
 * @param {string} id
 * @return {?gdata.docs.DocumentListEntry}
 */
gdata.docs.ui.DocPicker.prototype.getEntry = function(id) {
  return this.idToEntryMap_[id];
};

/**
 * A function that takes a click event on an element
 * in the doclist, finds the corresponding DocumentListEntry, and requests
 * the document's content for display in this picker's viewer.
 * @param {goog.events.Event} e A click event.
 * @private
 */
gdata.docs.ui.DocPicker.prototype.handleClick_ = function(e) {
  var el = /** @type {Element} */ (e.target);
  var dataEntryId = el.getAttribute('data-entry-id');
  if (!dataEntryId) return;

  if (goog.dom.classes.has(el, 'entryDetails')) {
    // Show the popup menu the user can click to bring up the details.
    var entry = this.idToEntryMap_[dataEntryId];
    var menu = this.popupMenu_;
    menu.setModel({entry: entry, el: el});
    menu.showAtElement(
        el,
        goog.positioning.Corner.TOP_RIGHT,
        goog.positioning.Corner.BOTTOM_RIGHT);    
  } else if (goog.dom.classes.has(el, 'doclistentry')) {
    // Title for the doc list entry.
    this.displayEntry(dataEntryId);
  }
};

/**
 * Label for the 'change title' menu item.
 * @type {string}
 */
gdata.docs.ui.DocPicker.CHANGE_TITLE = 'Change Title';

/**
 * Label for the 'edit details' menu item.
 * @type {string}
 */
gdata.docs.ui.DocPicker.EDIT_DETAILS = 'Edit Details';

/**
 * @return {goog.ui.PopupMenu}
 */
gdata.docs.ui.DocPicker.prototype.createMenu = function() {
  var dom = this.getPickerDom();
  var menu = new goog.ui.PopupMenu(dom);
  
  var changeTitle = new goog.ui.MenuItem(
      gdata.docs.ui.DocPicker.CHANGE_TITLE,
      null, // opt_model
      dom);
  menu.addChild(changeTitle, true /* render */);

  var editDetails = new goog.ui.MenuItem(
      gdata.docs.ui.DocPicker.EDIT_DETAILS,
      null, // opt_model
      dom);
  menu.addChild(editDetails, true /* render */);

  menu.render();
  return menu;
};

/**
 * @param {goog.events.Event} e
 */
gdata.docs.ui.DocPicker.prototype.onMenuItemClicked_ = function(e) {
  var menuItem = /** @type {goog.ui.MenuItem} */ (e.target);
  var content = e.target.getContent();
  var menu = /** @type {goog.ui.PopupMenu} */ (menuItem.getParent());
  var record = menu.getModel();
  var entry = /** @type {gdata.docs.DocumentListEntry} */ (record.entry);
  var buttonEl = /** @type {Element} */ (record.el);
  if (content == gdata.docs.ui.DocPicker.CHANGE_TITLE) {
    var title = prompt('Choose a new title:', entry.getTitle());
    if (title != null) {
      entry.setTitle(title);

      // Update the UI in the picker.
      var soyData = {
        hasViewer: this.hasViewer(),
        entry: {
          url: entry.getUrl(),
          title: entry.getTitle(),
          id: entry.getId()
        }
      };
      soy.renderElement(buttonEl.parentNode,
          gdata.viewer.templates.listEntry,
          soyData);
    }
  } else if (content == gdata.docs.ui.DocPicker.EDIT_DETAILS) {
    alert('Not implemented yet!');
  }
};

/** @inheritDoc */
gdata.docs.ui.DocPicker.prototype.disposeInternal = function() {
  gdata.docs.ui.DocPicker.superClass_.disposeInternal.call(this);
  this.handler_.dispose();
  goog.dispose(this.popupMenu_);
};


/**
 * @param {Element} viewerEl Element into which the viewer should be drawn.
 * @param {Object=} options May have the following keys:
 *     <li> {boolean} stripHeadHtml If specified, the contents of the &lt;head>
 *         element added by Google Docs will be stripped from the HTML inserted
 *         into the viewer.
 * @constructor
 * @extends {goog.events.EventTarget}
 */
gdata.docs.ui.DocViewer = function(viewerEl, options) {
  goog.events.EventTarget.call(this);
  options = options || {};

  /**
   * @type {boolean}
   * @private
   */
  this.stripHeadHtml_ = !!options.stripHeadHtml;

  /**
   * @type {Element}
   * @private
   */
  this.viewerEl_ = viewerEl;

  // This function is exported so that the IFRAME created by display() can call
  // it via its src attribute.
  goog.exportSymbol('viewerDisplayHtml', function(id) {
    var html = gdata.docs.ui.DocViewer.viewMap_[id];
    delete gdata.docs.ui.DocViewer.viewMap_[id];
    return html;
  });

  // This function is exported so that the IFRAME created by display() can call
  // it via its onload attribute.
  goog.exportSymbol('viewerIsLoaded', function(id, iframeEl) {
    var doc = goog.dom.getFrameContentDocument(iframeEl);

    // Workaround for https://bugs.webkit.org/show_bug.cgi?id=15484
    if (goog.userAgent.WEBKIT && !doc.body.firstChild) {
      // If the body does not have any content, then this is a spurious onload
      // event from the iframe.
      return;
    }
    
    var viewer = gdata.docs.ui.DocViewer.loadMap_[id];
    delete gdata.docs.ui.DocViewer.loadMap_[id];
    if (viewer.postLoadProcessor_) {
      viewer.postLoadProcessor_(doc);
    }

    // Notify listeners that the document has been loaded.
    var docLoadedEvent = new goog.events.Event(
        gdata.docs.ui.DocViewer.EventType.DOCUMENT_LOADED, viewer);
    viewer.dispatchEvent(docLoadedEvent);
  });
};
goog.inherits(gdata.docs.ui.DocViewer, goog.events.EventTarget);


/**
 * @type {number}
 * @private
 */
gdata.docs.ui.DocViewer.viewCount_ = 0;


/**
 * Maps an id to a string of HTML.
 * @type {Object}
 * @private
 */
gdata.docs.ui.DocViewer.viewMap_ = {};


/**
 * Maps an id to a DocViewer.
 * @type {Object}
 * @private
 */
gdata.docs.ui.DocViewer.loadMap_ = {};


/**
 * @type {gdata.docs.DocumentListEntry}
 * @private
 */
gdata.docs.ui.DocViewer.prototype.currentEntry_;


/**
 * @type {function(Document)?}
 * @private
 */
gdata.docs.ui.DocViewer.prototype.postLoadProcessor_;


/**
 * @type {function(string, Document):string}
 * @private
 */
gdata.docs.ui.DocViewer.prototype.preSaveProcessor_;


/**
 * If set, this function will be applied to the Document in the viewer after the
 * HTML as been loaded.
 * @param {?function(Document)} processor
 */
gdata.docs.ui.DocViewer.prototype.setPostLoadProcessor = function(processor) {
  this.postLoadProcessor_ = processor;
};

/**
 * Creates a fresh IFRAME and writes the HTML into it verbatim. The HTML may
 * start with a &lt;DOCTYPE> element, which is convenient as the doctype cannot
 * be changed programmatically once a web page is loaded.
 * 
 * @param {gdata.docs.DocumentListEntry} entry
 */
gdata.docs.ui.DocViewer.prototype.display = function(entry) {
  entry.getContentAsHtml(this.createContentHandler_(entry), alert);
};

/**
 * @param {gdata.docs.DocumentListEntry} entry
 * @return {function(string)} a function that takes a string of HTML and
 *     displays it in this picker's viewer.
 */
gdata.docs.ui.DocViewer.prototype.createContentHandler_ = function(entry) {
  var self = this;
  return function(html) {
    self.insertHtml_(html, entry);
  };
};

/**
 * Creates a fresh IFRAME and writes the HTML into it verbatim. The HTML may
 * start with a &lt;DOCTYPE> element, which is convenient as the doctype cannot
 * be changed programmatically once a web page is loaded.
 * @param {string} html The HTML to display.
 * @param {gdata.docs.DocumentListEntry} entry The entry associated with the
 *     HTML content.
 * @private
 */
gdata.docs.ui.DocViewer.prototype.insertHtml_ = function(html, entry) {
  this.currentEntry_ = entry;
  var id = gdata.docs.ui.DocViewer.viewCount_++;

  if (this.stripHeadHtml_) {
    // The HTML sent down from Google Docs should be well-formed, but it
    // contains style, script, and meta tags in the HEAD element that may be
    // undesirable. This regex is used to get only the content within the body
    // tag (it ignores the attributes of the body tag itself as it contains
    // an onload handler that calls a script defined in the HEAD element).
    //
    // The [\s\S] trick comes from http://xregexp.com/flags/#singleline
    // to get around the fact that the dot does not match all characters in
    // JavaScript.
    var re = /<body[^>]+>([\s\S]*)<\/body>/m;
    var match = html.match(re);
    if (match) {
      var soyData = { innerHtml: match[1] };
      html = gdata.viewer.templates.documentHtml(soyData);
    }
  }
  gdata.docs.ui.DocViewer.viewMap_[id] = html;
  gdata.docs.ui.DocViewer.loadMap_[id] = this;

  var soyData = { id: id };
  this.viewerEl_.innerHTML = gdata.viewer.templates.iframeElement(soyData);
};

/**
 * Returns the entry currently being displayed, if any.
 * @return {gdata.docs.DocumentListEntry?}
 */
gdata.docs.ui.DocViewer.prototype.getEntry = function() {
  return this.currentEntry_;
};


/**
 * @param {function(string, Document):string} processor
 */
gdata.docs.ui.DocViewer.prototype.setPreSaveProcessor = function(processor) {
  this.preSaveProcessor_ = processor;
};


/**
 * @param {function(string)} callback That receives the response from the
 *     server, if successful.
 * @param {function(string)} errorCallback
 */
gdata.docs.ui.DocViewer.prototype.save = function(callback, errorCallback) {
  try {
    var iframeEl = this.viewerEl_.firstChild;
    var doc = goog.dom.getFrameContentDocument(iframeEl);
    var content = doc.body.innerHTML;
    if (this.preSaveProcessor_) {
      content = this.preSaveProcessor_(content, doc);
    }
    this.getEntry().update(content, callback, errorCallback);
  } catch (e) {
    errorCallback(e.message);
  }
};


/**
 * @enum {string}
 */
gdata.docs.ui.DocViewer.EventType = {
  DOCUMENT_LOADED: 'doc-viewer-document-loaded'
};
